/*
 * Copyright (C) 2014 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.android.utils;

import android.accessibilityservice.AccessibilityService;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.Paint.Style;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff.Mode;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.GradientDrawable.Orientation;
import android.os.Build;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.TextUtils;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.view.accessibility.AccessibilityNodeInfo;
import com.android.utils.labeling.CustomLabelManager;
import com.android.utils.traversal.NodeFocusFinder;
import com.android.utils.widget.SimpleOverlay;

import java.util.ArrayList;
import java.util.List;

/**
 * Facilitates search of the nodes on the screen. Nodes are matched by description, and the
 * accessibility focus is moved to the matched node.
 */
public class NodeSearch {
    /**
     * A formatter that determines the text to display given the search query, and the size at
     * which that text should be displayed.
     */
    public interface SearchTextFormatter {
        /**
         * Get the size, in pixels, at which the text returned by {@link #getDisplayText} should be
         * displayed.
         *
         * @return The desired text size.
         */
        public float getTextSize();

        /**
         * Get the text that should be displayed for a certain search query.
         *
         * @param queryText The search query.
         * @return The text that should be displayed for this search.
         */
        public String getDisplayText(String queryText);
    }

    /**
     * A filter that may exclude some nodes from the search.
     */
    public interface SearchResultFilter {
        /**
         * Check if a node should be excluded from the search.
         *
         * @param node The node that is being checked.
         * @return {@code true} if the node should be excluded, or {@code false} otherwise.
         */
        public boolean shouldFilter(AccessibilityNodeInfoCompat node);
    }

    /** The current search query. */
    private final StringBuilder mQueryText = new StringBuilder();

    /** The last matched node for the current search. */
    private final AccessibilityNodeInfoRef mMatchedNode = new AccessibilityNodeInfoRef();

    /** The accessibility service. */
    private final AccessibilityService mAccessibilityService;

    /** The label manager used to obtain node descriptions. */
    private final CustomLabelManager mLabelManager;

    /** The search result filters that should be used, if any. */
    private final List<SearchResultFilter> mFilters = new ArrayList<>();

    /** The overlay that is used to show the current search. */
    private final SearchOverlay mSearchOverlay;

    /** Whether or not there is an active search. */
    private boolean mActive;

    /**
     * Create a new NodeSearch instance.
     *
     * @param accessibilityService The accessibility service.
     * @param labelManager The custom label manager, or {@code null} if the API version does not
     * support custom labels.
     * @param textFormatter The formatter for the search display.
     */
    public NodeSearch(AccessibilityService accessibilityService,
            CustomLabelManager labelManager, SearchTextFormatter textFormatter) {
        mAccessibilityService = accessibilityService;
        mLabelManager = labelManager;
        mSearchOverlay = new SearchOverlay(accessibilityService, mQueryText, textFormatter);
    }

    /**
     * Create a new NodeSearch instance with filters.
     *
     * @param accessibilityService The accessibility service.
     * @param labelManager The custom label manager, or {@code null} if the API version does not
     * support custom labels.
     * @param textFormatter The formatter for the search display.
     * @param filters The filters that should be used.
     */
    public NodeSearch(AccessibilityService accessibilityService,
            CustomLabelManager labelManager, SearchTextFormatter textFormatter,
            List<SearchResultFilter> filters) {
        this(accessibilityService, labelManager, textFormatter);
        mFilters.addAll(filters);
    }

    /**
     * Start a search. Shows the search overlay.
     */
    public void startSearch() {
        mSearchOverlay.show();
        mActive = true;
    }

    /**
     * Stop the current search. Hides the search overlay and clears the search query.
     */
    public void stopSearch() {
        mMatchedNode.clear();
        mQueryText.setLength(0);
        mSearchOverlay.hide();
        mActive = false;
    }

    /**
     * Check if this instance is actively handling a search.
     *
     * @return {@code true} if there is an active search, or {@code false} otherwise.
     */
    public boolean isActive() {
        return mActive;
    }

    /**
     * Try to add some text to the search query. The text is only added if there are search results
     * for the new query, in which case the matched node may change.
     *
     * @param newText The text to add.
     * @return {@code true} if the text was added successfully, or {@code false} otherwise.
     */
    public boolean tryAddQueryText(CharSequence newText) {
        int initLength = mQueryText.length();
        mQueryText.append(newText);
        if (evaluateSearch()) {
            mSearchOverlay.refreshOverlay();
            return true;
        }
        // Search failed, go back to old text.
        mQueryText.delete(initLength, mQueryText.length());
        return false;
    }

    /**
     * Delete the last entered character if it exists. Has no effect on the matched node.
     *
     * @return {@code true} if a character was successfully deleted, or {@code false} if there was
     * no character to delete.
     */
    public boolean backspaceQueryText() {
        int length = mQueryText.length();
        if (length > 0) {
            mQueryText.deleteCharAt(length - 1);
            mSearchOverlay.refreshOverlay();
            return true;
        }

        return false;
    }

    /**
     * Get the current search query.
     *
     * @return The current search query. May be empty.
     */
    public String getCurrentQuery() {
        return mQueryText.toString();
    }

    /**
     * Get the text of the currently matched node.
     *
     * @return The text of the current match. May be empty, for example if no match has been found.
     */
    public String getMatchText() {
        final AccessibilityNodeInfoCompat currentMatch = mMatchedNode.get();
        if (currentMatch == null) {
            return "";
        }

        final CharSequence nodeText =
                AccessibilityNodeInfoUtils.getNodeText(currentMatch, mLabelManager);
        if (nodeText == null) {
            return "";
        }

        return nodeText.toString();
    }

    /**
     * Searches for the next result matching the current search query in the specified direction.
     * Ordering of results taken from linear navigation.
     *
     * @param direction The direction in which to search, {@link NodeFocusFinder#SEARCH_FORWARD} or
     * {@link NodeFocusFinder#SEARCH_BACKWARD}.
     * @return {@code true} if a match was found, or {@code false} otherwise.
     */
    public boolean nextResult(int direction) {
        AccessibilityNodeInfoRef next = new AccessibilityNodeInfoRef();
        next.reset(NodeFocusFinder.focusSearch(getCurrentNode(), direction));

        AccessibilityNodeInfoCompat focusableNext = null;
        try {
            while (next.get() != null) {
                if (nodeMatchesQuery(next.get())) {
                    // Even if the text matches, we need to make sure the node should be focused or
                    // has a parent that should be focused.
                    focusableNext = AccessibilityNodeInfoUtils.findFocusFromHover(next.get());

                    // Only count this as a match if it doesn't lead to the same parent.
                    if (focusableNext != null && !focusableNext.isAccessibilityFocused()) {
                        break;
                    }
                }
                next.reset(NodeFocusFinder.focusSearch(next.get(), direction));
                if (focusableNext != null) {
                    focusableNext.recycle();
                    focusableNext = null;
                }
            }

            if (focusableNext == null) {
                return false;
            }

            mMatchedNode.reset(next);
            return PerformActionUtils.performAction(focusableNext,
                    AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
        } finally {
            if (focusableNext != null) {
                focusableNext.recycle();
            }
            next.recycle();
        }
    }

    /**
     * Re-evaluate the search (perhaps, for example, because the screen content changed).
     */
    public void reEvaluateSearch() {
        mMatchedNode.reset(AccessibilityNodeInfoUtils.refreshNode(mMatchedNode.get()));
        evaluateSearch();
    }

    /**
     * Check if the search has found anything.
     *
     * @return {@code true} if a match has been found, or {@code false} otherwise.
     */
    public boolean hasMatch() {
        return !AccessibilityNodeInfoRef.isNull(mMatchedNode);
    }

    /**
     * Get the last matching node, if available, or the currently focused node otherwise.
     *
     * @return The last matched node, if a match was previously made. If no match has been made yet,
     * returns the currently focused node.
     */
    AccessibilityNodeInfoCompat getCurrentNode() {
        return AccessibilityNodeInfoRef.isNull(mMatchedNode)
                ? FocusFinder.getFocusedNode(mAccessibilityService, true) : mMatchedNode.get();
    }

    /**
     * Evaluates the search with the current query, searching from the last matched node forward.
     *
     * @return {@code true} if a match was found, or {@code false} otherwise.
     */
    private boolean evaluateSearch() {
        // First check if current selected result still matches.
        return nodeMatchesQuery(mMatchedNode.get()) || nextResult(NodeFocusFinder.SEARCH_FORWARD);

    }

    /**
     * Check if the specified node's description matches the current query text (case insensitive).
     *
     * @param node The node to check.
     * @return {@code true} if the node's description contains the current query text (ignoring
     * case), or {@code false} otherwise.
     */
    private boolean nodeMatchesQuery(AccessibilityNodeInfoCompat node) {
        // When no query text, consider everything a match.
        if (TextUtils.isEmpty(mQueryText)) {
            return AccessibilityNodeInfoUtils.shouldFocusNode(node);
        }

        if (node == null) {
            return false;
        }

        for (SearchResultFilter filter : mFilters) {
            if (filter.shouldFilter(node)) {
                return false;
            }
        }

        CharSequence nodeText = AccessibilityNodeInfoUtils.getNodeText(node, mLabelManager);
        if (nodeText == null) {
            return false;
        }

        String queryText = mQueryText.toString().toLowerCase();
        return nodeText.toString().toLowerCase().contains(queryText);
    }

    /* Package private methods for testing. */

    /* package */ void setQueryTextForTest(String text) {
        mQueryText.setLength(0);
        mQueryText.append(text);
    }

    /**
     * Controls the view that shows search overlay content.
     */
    private static class SearchOverlay extends SimpleOverlay implements DialogInterface {
        /** The search view. */
        private final SearchView mSearchView;

        /**
         * Creates the overlay with it initially invisible.
         */
        public SearchOverlay(Context context, StringBuilder queryText, SearchTextFormatter
                textFormatter) {
            super(context);

            mSearchView = new SearchView(context, queryText, textFormatter);

            // Make overlay appear on everything it can.
            LayoutParams params = getParams();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
                params.type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY;
            } else {
                params.type = LayoutParams.TYPE_SYSTEM_ERROR;
            }
            setParams(params);

            setContentView(mSearchView);
        }

        /**
         * Called when the the overlay is shown.
         */
        @Override
        protected void onShow() {
            mSearchView.show();
        }

        /**
         * Refreshes the overlaid text display.
         */
        public void refreshOverlay() {
            mSearchView.invalidate();
        }

        @Override
        public void cancel() {
            dismiss();
        }

        @Override
        public void dismiss() {
            // This also effectively hides the search view.
            hide();
        }
    }

    /**
     * View handling drawing of incremental search overlay.
     */
    private static class SearchView extends SurfaceView {
        /** The colors to use for the gradient background. */
        private static final int GRADIENT_INNER_COLOR = 1996488704; // #7000
        private static final int GRADIENT_OUTER_COLOR = 1996488704;

        /** The surface holder onto which the view is drawn. */
        private SurfaceHolder mHolder;

        /** The background. */
        private final GradientDrawable mGradientBackground;

        /** The formatter for the text that will be displayed in this view. */
        private final SearchTextFormatter mTextFormatter;

        /**
         * The search query text. Synced to the StringBuilder in NodeSearch so we shouldn't
         * modify it here.
         */
        private final StringBuilder mQueryText;

        private final SurfaceHolder.Callback mSurfaceCallback =
                new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                mHolder = holder;
            }

            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {
                mHolder = null;
            }

            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
                invalidate();
            }
        };

        public SearchView(Context context, StringBuilder queryText, SearchTextFormatter
                textFormatter) {
            super(context);

            mQueryText = queryText;
            mTextFormatter = textFormatter;

            final SurfaceHolder holder = getHolder();
            holder.setFormat(PixelFormat.TRANSLUCENT);
            holder.addCallback(mSurfaceCallback);

            // Gradient colors.
            final int[] colors = new int[] {GRADIENT_INNER_COLOR, GRADIENT_OUTER_COLOR};
            mGradientBackground = new GradientDrawable(Orientation.TOP_BOTTOM, colors);
            mGradientBackground.setGradientType(GradientDrawable.LINEAR_GRADIENT);
        }

        public void show() {
            invalidate();
        }

        @Override
        public void invalidate() {
            super.invalidate();

            final SurfaceHolder holder = mHolder;
            if (holder == null) {
                return;
            }

            final Canvas canvas = holder.lockCanvas();
            if (canvas == null) {
                return;
            }

            // Clear the canvas.
            canvas.drawColor(Color.TRANSPARENT, Mode.CLEAR);

            if (getVisibility() != View.VISIBLE) {
                holder.unlockCanvasAndPost(canvas);
                return;
            }

            final int width = getWidth();
            final int height = getHeight();

            // Draw the pretty gradient background.
            mGradientBackground.setBounds(0, 0, width, height);
            mGradientBackground.draw(canvas);

            Paint paint = new Paint();
            paint.setColor(Color.WHITE);
            paint.setStyle(Style.FILL);
            paint.setTextAlign(Align.CENTER);
            paint.setTextSize(mTextFormatter.getTextSize());
            canvas.drawText(
                    mTextFormatter.getDisplayText(mQueryText.toString()),
                    width / 2.0f,
                    height / 2.0f,
                    paint);

            holder.unlockCanvasAndPost(canvas);
        }
    }
}
